<?php
/*

see for ideas
http://www.hackdiary.com/archives/000060.html

0.1 - 10/11/04
- initial release

0.2 - 10/15/04
- sends user-agent
- improved error handling, added error codes
- added HTTP header parsing and handling
- now disables all calls after user is throttled (receive http status 503)
- added support for /all method with get_all_posts()
- fixed the delete_post method

0.3 - future
- TODO - cookie support
- TODO - implement support for http 1.1 persistent connections
- TODO - caching

*/

// error codes
define('DEL_ERR_NOCREDENTIALS',1);
define('DEL_ERR_THROTTLED',2);
define('DEL_ERR_MANGLEDHEADERS',3);
define('DEL_ERR_SOCKETCONNECT',4);
define('DEL_ERR_SOCKETWRITE',5);
define('DEL_ERR_XMLPARSE',6);

class DeliciousClient
{
	var $title = 'Delicious-x-4';
	var $version = '0.2';
	var $host = 'del.icio.us';
	var $port = 80;
	var $username = false;
	var $password = false;
	var $error = false; // is set to an error code upon any error
	var $debug_flag = 0; // 1 = append to to $del->debug_str, 2 = echo() out line by line
	var $debug_str = "";
	var $throttled = false; // set to true if server returns 503, deny any future requests
	var $incoming_headers = array();
	var $request = "";
	var $response = "";
	var $cookie = false;
  public $server_addr = "http://delicious.com";
	
	// constructor
	function delicious($username,$password)
	{
		$this->username = $username;
		$this->password = $password;
	}
	
	// send http message, return parsed response
	function send($path)
	{
		// throttle check
		if($this->throttled)
		{
			$this->setError(DEL_ERR_THROTTLED);
			return false;
		}
		
		// check that credentials have been set
		if(!$this->username || !$this->password)
		{
			$this->setError(DEL_ERR_NOCREDENTIALS);
			return false;
		}
		
		// build http request message
		$msg =
		"GET http://".$this->host."$path HTTP/1.0\r\n"
		.'User-Agent: '.$this->title.'/'.$this->version."\r\n";
		
		// cookie or credentials
		$msg .= 'Authorization: Basic '.base64_encode($this->username.':'.$this->password)."\r\n";
		/*
		if(!$this->cookie)
		{
			$msg .= 'Authorization: Basic '.base64_encode($this->username.':'.$this->password)."\r\n";
		}
		else
		{
			$msg .= 'Cookie:'.$this->cookie."\r\n";
		}
		*/
		
		// close message
		$msg .= "\r\n";
		
		$this->request = $msg;
		//$this->debug('REQUEST:');
		//$this->debug('<xmp>'.$msg.'</xmp>');
		
		// make call
		if( $fp = pfsockopen( $this->host, $this->port) )
		{
			if( fwrite($fp, $msg) )
			{
				$response = '';
				while (!feof($fp))
				{
					$response .= fread($fp, 8192);
				}
				
				fclose($fp);
				
				$this->response = $response;
				//$this->debug('RESPONSE:');
				//$this->debug('<xmp>'.$response.'</xmp>');
				
				// parse response message
				$pos = strpos($response,"\r\n\r\n");
				if($pos < 1)
				{
					$pos = strpos($data,"\n\n");
				}
				
				// separate headers and body
				if($headers = $this->parseHeaders(trim(substr($response,0,$pos))))
				{
					foreach($headers as $k => $v)
					{
						$this->debug("HTTP Response Header: $k -> $v");
					}
					
					// mangled headers?
					if(!isset($headers['status']))
					{
						$this->debug('send(): no status in headers');
						$this->setError(DEL_ERR_MANGLEDHEADERS);
						return false;
					}
					
					// handle 503
					if($headers['status'] == 503)
					{
						$this->throttled = true;
						$this->setError(DEL_ERR_THROTTLED);
						return false;
					}
					
					// process cookie
					if(isset($headers['set-cookie']))
					{
						$this->cookie = substr( $headers['set-cookie'], 0, strpos($headers['set-cookie'],';') ).';';
						$this->debug('send(): setting cookie: '.$this->cookie);
					}
					
					// handle unchanged
					
					// get xml response
					$xml = trim(substr($response,$pos));
					
					// if xml response
					if(strlen($xml) > 0)
					{
						$this->debug('send(): parsing xml');
						// parse into assoc array and return
						if($result = $this->parseResponse($xml))
						{
							return $result;
						}
						else
						{
							$this->debug('got parser error');
							$this->setError(DEL_ERROR_XMLPARSE);
						}
					}
					else
					{
						return array();
					}
				}
				else
				{
					// no proper headers found - mangled response?
					$this->setError(DEL_ERR_MANGLEDHEADERS);
					return false;
				}
			}
			else
			{
				$this->setError(DEL_ERR_SOCKETWRITE);
				return false;
			}
		}
		else
		{
			$this->setError(DEL_ERR_SOCKETCONNECT);
			return false;
		}
	}
	
	// parse headers
	function parseHeaders($str)
	{
		//$this->debug("parseHeaders(): header string: $str");
		// find linebreak type
		$lb = false;
		$pos = strpos($str,"\r\n");
		if($pos > 1){
			$lb = "\r\n";
		} else {
			$pos = strpos($str,"\n");
			if($pos > 1){
				$lb = "\n";
			}
		}
		
		// if linebreak is found
		if(!$lb)
		{
			$this->debug('parseHeaders(): no linebreak found');
			return false;
		}
		else
		{
			$header_array = explode( $lb, $str );
			foreach($header_array as $header_line){
				$arr = explode(':',$header_line, 2);
				if(count($arr) > 1){
					$header_name = strtolower(trim($arr[0]));
					$this->incoming_headers[$header_name] = trim($arr[1]);
				} else if (isset($header_name)) {
					// append continuation line to previous header
					$this->incoming_headers[$header_name] .= $lb . ' ' . $header_line;
				} elseif ( strstr($header_line,'HTTP')) {
					$arr = explode(' ', $header_line);
					$this->incoming_headers['status'] = $arr[1];
				}
			}
			return $this->incoming_headers;
		}
	}
	
	// parse response
	function parseResponse($xml)
	{
		$parser = xml_parser_create('');
		xml_parser_set_option($parser,XML_OPTION_CASE_FOLDING,0);
		xml_parser_set_option($parser,XML_OPTION_SKIP_WHITE,1);
		$return = xml_parse_into_struct($parser,$xml,$values,$tags);
		
		//$this->debug( 'parseResponse(): xml_parse_into_struct return: '.$return);
		
		// handle errors
		if($return == 0)
		{
			$this->debug( 'parseResponse(): xml error - '.xml_error_string( xml_get_error_code($parser) ) );
			return false;
		}
		else
		{
			foreach($tags as $tagname => $indices)
			{
				foreach($indices as $index)
				{
					$id = uniqid('');
					
					if($values[$index]['type'] != 'close')
					{
						// store element details
						$content[$id] = $values[$index];
						
						$tagname = $values[$index]['tag'];
						
						// set self to value of last open tag flag
						if($values[$index]['type'] == 'open')
						{
							$lastOpenTagAtLevel[ $values[$index]['level'] ] = $id;
						}
						
						// get parent id
						$myParent = $lastOpenTagAtLevel[ ($values[$index]['level']-1) ];
						
						// if root, flag, and don't add to parent
						if($values[$index]['level'] == 1)
						{
							$root = $id;
						}
						// if complete, add self to parent result
						elseif($values[$index]['type'] == 'complete')
						{
							$content[ $myParent ]['result'][$tagname][] = $values[$index]['attributes'];
						}
						// if open tag, add reference to my result to parents result
						elseif($values[$index]['type'] == 'open')
						{
							$content[ $myParent ]['result'][$tagname][] = &$values[$index]['result'];
						}
					}
				}
			}
			
			// handle case where xml document is a single complete tag w/ no children
			if(!isset($content[$root]['result']))
			{
				$result = $content[$root]['attributes'];
			}
			else
			{
				$result = array_shift($content[$root]['result']);
			}
			xml_parser_free($parser);
			
			return $result;
		}
	}
	
	// set errors
	function setError($str)
	{
		$this->error = $str;
	}
	
	// get errors
	function getError()
	{
		return $this->error;
	}
	
	// handle debug output
	function debug($str)
	{
		if($this->debug_flag == 1)
		{
			$this->debug_str .= $str."\n";
		}
		elseif($this->debug_flag == 2)
		{
			echo $str.'<br>';
		}
	}
	
	function htmlvar_dump($var)
	{
		ob_start();
		var_dump($var);
		$str = ob_get_contents();
		ob_end_clean();
		return '<pre>'.$str.'</pre>';
	}
	/*
	function: http://del.icio.us/api/posts/dates?
	&tag= filter by this tag - optional
	returns a list of dates with the number of posts at each date. 
	[tag] adds a filter
	*/
	function get_post_dates($tag=false)
	{
		// build path and query string
		$queryString =
		'/api/posts/dates?'; // action
		
		if($tag)
		{
			$queryString .= '&tag='.urlencode($tag);
		}
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/tags/get?
	returns a list of tags the user has used.
	*/
	function get_tags()
	{
		// build path and query string
		$queryString =
		'/api/tags/get?'; // action
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/posts/get?
		&tag= filter by this tag - optional
		&dt= filter by this date
	returns a list of posts on a given date, filtered by tag. if no 
	date is supplied, most recent date will be used
	*/
	function get_posts($tag=false,$dt=false)
	{
		// build path and query string
		$queryString =
		'/api/posts/get?'; // action
		
		if($tag)
		{
			$queryString .= '&tag='.urlencode($tag);
		}
		
		if($dt)
		{
			$queryString .= '&dt='.urlencode($dt);
		}
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/posts/recent?
		&tag= filter by this tag - optional
		&count= number of items to retrieve - optional (defaults to 15)
	returns a list of most recent posts, possibly filtered by tag.
	*/
	function get_recent_posts($tag=false,$count=false)
	{
		// build path and query string
		$queryString =
		'/api/posts/recent?'; // action
		
		if($tag)
		{
			$queryString .= '&tag='.urlencode($tag);
		}
		
		if($count)
		{
			$queryString .= '&count='.urlencode($count);
		}
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/posts/all
	returns all posts. use sparingly.
	*/
	function get_all_posts()
	{
		// build path and query string
		$queryString = '/api/posts/all'; // action
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/posts/add?
		&url= url for post
		&description= description for post
		&extended= extended for post
		&tags= space-delimited list of tags
		&dt= datestamp for post, format "CCYY-MM-DDThh:mm:ssZ"
	makes a post to delicious
	*/
	function add_post($link, $title, $content, $category, $date)
	{
		// prep data
		//$date = date('Y-m-d\TH:i:s\Z', strtotime($date) );
		
		// build path and query string
		$queryString =
		'/api/posts/add?'					// action
		.'&url='.$link						// link for post
		.'&description='.urlencode($title)	// description for post
		.'&extended='.urlencode($content)	// extended for post
		.'&tags='.urlencode($category) 		// space-delimited list of tags
		.'&dt='.urlencode($date); 			// datestamp for post, format "CCYY-MM-DDThh:mm:ssZ"
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/tags/rename?
		&old= old tag
		&new= new tag
	renames tags across all posts
	*/
	function rename_tag($old_tag,$new_tag)
	{
		// build path and query string
		$queryString =
		'/api/tags/rename?' // action
		.'&old='.urlencode($old_tag)
		.'&new='.urlencode($new_tag);
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/inbox/get?
			&dt= filter by this date
	returns a list of inbox entries
	*/
	function get_inbox($dt)
	{
		// build path and query string
		$queryString =
		'/api/inbox/get?' // action
		.'&dt='.urlencode($dt);
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/inbox/dates?
	returns a list of dates containing inbox entries
	*/
	function get_inbox_dates()
	{
		// build path and query string
		$queryString =
		'/api/inbox/dates?'; // action
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/inbox/subs?
	returns a list of your subscriptions
	*/
	function get_subscriptions()
	{
		// build path and query string
		$queryString =
		'/api/inbox/subs?'; // action
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/inbox/sub?
		&user= username
		&tag = tag - optional, leave blank for all posts
	adds a subscription
	*/
	function subscribe($user,$tag=false)
	{
		// build path and query string
		$queryString =
		'/api/inbox/sub?' // action
		.'&user='.urlencode($user);
		
		if($tag)
		{
			$queryString .= '&tag='.urlencode($tag);
		}
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/inbox/unsub?
		&user= username
		&tag = tag - optional, leave blank for all posts
	removes a subscription
	*/
	function unsubscribe($user,$tag=false)
	{
		// build path and query string
		$queryString =
		'/api/inbox/unsub?' // action
		.'&user='.urlencode($user);
		
		if($tag)
		{
			$queryString .= '&tag='.urlencode($tag);
		}
		
		// send
		return $this->send($queryString);
	}
	
	/*
	function: http://del.icio.us/api/posts/delete?url=
	deletes a post
	*/
	function delete_post($url)
	{
		// build path and query string
		$queryString =
		'/api/posts/delete'
		.'?url='.urlencode($url);
		
		// send
		return $this->send($queryString);
	}
  
  
  /* UTILITY FUNCTIONS */

  public function & call_method($method, $username) {
    switch($method) {
      case "fetchcontacts": 
        $xml = $this->post_request('network/' . $username, array());
        // puzzle the required snipplet ;-)
        $start  = strpos($xml,'<ul id="network-groups"');
        $xml = substr($xml,$start);
        $start  = strpos($xml,'>');
        $end    = strpos($xml,'</ul>');
        $xml = substr($xml,$start,$end-$start);
        $xml = substr($xml, strpos($xml, 'class="user" title="')+20);
        $snippets = explode('class="user" title="', $xml);
        $result = array();
        foreach($snippets as $snippet) $result[] = substr($snippet, 0, strpos($snippet, '"'));
        break;
      case "fetchcontact":
        $contact_data=array();
        $xml = $this->post_request($username, array());

        // puzzle the required snipplet ;-)
        
        // find the user name
        $start  = strpos($xml,' id="pagetitleContent"');
        $xml = substr($xml,$start);
        $start = strpos($xml, '<em');
        $xml = substr($xml,$start);
        $start = strpos($xml, '>')+1;
        $xml = substr($xml,$start);
        $end = strpos($xml,"'");
        $contact_data["name"] = substr($xml,0,$end);
        
        // find optional data
        $start  = strpos($xml,'id="userprofile"');
        if($start !== false) {
          $xml = substr($xml,$start);
          $www_start  = strpos($xml,'Site:');
          if($www_start!==false) { // Website angegeben
            $www = substr($xml,$www_start);
            $www_start  = strpos($xml,'<a href="') + 9;
            $www = substr($xml,$www_start);
            $end = strpos($www,"\"");
            $www = substr($www,0,$end);
            $contact_data["homepage"] = $www;
            $start  = strpos($xml,'>',$www_start)+1;
          } else $start  = 0;
          $end    = strpos($xml,'</');
          $name = substr($xml,$start,$end-$start);
          $name = str_replace("&nbsp;"," ",$name);
        } else $name="";
        $contact_data["accountName"] = $username;
        $contact_data["nick"] = $contact_data["accountName"];

        $result = $contact_data;
        break;
    }
    return $result;
  }

  private function create_post_string($method, $params) {
    $params['method'] = $method;
    $params['session_key'] = $this->session_key;
    $params['api_key'] = $this->api_key;
    $params['call_id'] = microtime(true);
    if ($params['call_id'] <= $this->last_call_id) {
      $params['call_id'] = $this->last_call_id + 0.001;
    }
    $this->last_call_id = $params['call_id'];
    $post_params = array();
    foreach ($params as $key => &$val) {
      if (is_array($val)) $val = implode(',', $val);
      $post_params[] = $key.'='.urlencode($val);
    }
    return implode('&', $post_params);
  }

  public function post_request($method, $params) {

    $post_string = $this->create_post_string($method, $params);

    if (function_exists('curl_init')) {
      // Use CURL if installed...
      $ch = curl_init();
      curl_setopt($ch, CURLOPT_URL, $this->server_addr."/".$method);
      curl_setopt($ch, CURLOPT_POSTFIELDS, $post_string);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_USERAGENT, 'No Mozilla although open source (curl) ' . phpversion());
      $result = curl_exec($ch);
      curl_close($ch);
    } else {
      // Non-CURL based version...
      $context =
        array('http' =>
              array('method' => 'POST',
                    'header' => 'Content-type: application/x-www-form-urlencoded'."\r\n".
                                'User-Agent: No Mozilla although open source (non-curl) '.phpversion()."\r\n".
                                'Content-length: ' . strlen($post_string),
                    'content' => $post_string));
      $contextid=stream_context_create($context);
      $sock=fopen($this->server_addr."/".$method, 'r', false, $contextid);
      if ($sock) {
        $result='';
        while (!feof($sock))
          $result.=fgets($sock, 4096);
        fclose($sock);
      }
    }
    return $result;
  }

  public static function convert_simplexml_to_contacts_array($sxml) {
    $arr = array();
    $contacts=array();
    if ($sxml) {
      foreach ($sxml as $k => $v) {
        if($k=="a") {
          $contact = (string) $v;
          if(strlen($contact)) $contacts[]=$contact;
        }
        if ($sxml['list']) {
          $current = self::convert_simplexml_to_contacts_array($v);
          $arr[]   = $current;
          $contacts= $current;
        } else {
          $current = self::convert_simplexml_to_contacts_array($v);
          $arr[$k] = $current; 
          if(is_array($current) && sizeof($current)) $contacts=array_merge($contacts,$current);
        }
      }
    }
    return $contacts;
  }

  public static function convert_simplexml_to_array($sxml) {
    $arr = array();
    if ($sxml) {
      foreach ($sxml as $k => $v) {
        if ($sxml['list']) {
          $arr[] = self::convert_simplexml_to_array($v);
        } else {
          $arr[$k] = self::convert_simplexml_to_array($v);
        }
      }
    }
    if (sizeof($arr) > 0) {
      return $arr;
    } else {
      return (string)$sxml;
    }
  }
} 

class DeliciousClientException extends Exception {
}
?>
